﻿#region MIT License
/*

Copyright (C) 2011 by Timothy A. Barela

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

*/
#endregion

using System;
using System.Diagnostics;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Reflection;

namespace LineCommander
{
	/// <summary>
	/// The base class for CommandLines.
	/// </summary>
	/// <remarks>Derive and instantiate your own class from CommandLine.
	/// Call the Parse() method to parse an array of argument strings, and populate your CommandLine.</remarks>
	public abstract class CommandLine
	{
		public const string LineCommanderVersion = "11.11.0.3";
		static internal readonly char[] shortNameSeparators = new char[] {',',';','|'};

		List<string> values = new List<string>();
		/// <summary>
		/// Represents the list of all "Values", or commandline argument strings without a prefix, that
		/// come after all options.
		/// </summary>
		public IList<string> Values { get { return values; } }

		/// <summary>
		/// Gets whether the CommandLine has items in its Values list.
		/// </summary>
		[OptionIgnore]
		public bool HasValues { get { return values.Count > 0; } }
		
		private CommandLineConfiguration configuration = new CommandLineConfiguration();
		protected CommandLineConfiguration Configuration
		{
			get { return configuration; }
			set
			{
				if(value != null)
					configuration = value;
				else
					throw new ArgumentNullException("CommandLineConfiguration may not be set to null.");
			}
		}

		/// <summary>
		/// Parses an array of argument strings, populating all properties that have corresponding options.
		/// </summary>
		/// <param name="args">An array of argument strings, typically received from your entry point
		/// method.</param>
		public void Parse(string[] args)
		{
			bool reachedValues = false;//set to true, once we've reached the first raw value.
										//after this is set, any "named" options are an error.

			var options = createOptionSet();

			if(args.Length < 1)
				throw new CommandLineNoInputException(this);

			foreach(var argString in args)
			{
				//get name:value
				Argument argument = splitArgument(argString);

				if(argument.HasName)
				{
					if(reachedValues)
						throw new CommandLineException(this,string.Format("Option '{0}' must precede any Values.",argument.Name));  

					//this is an option
					Option option = null;

					if(options.TryGetValue(argument.Name,out option))
						option.SetValue(this,argument);
					else if(!Configuration.IgnoreUnknownOptions)
						throw new CommandLineException(this,string.Format("'{0}' is not a valid option.",argument.Name));
				}
				else
				{
					//this is a value
					Values.Add(argument.Value);
					reachedValues = true;
				}
			}
		}

		private Dictionary<string,Option> createOptionSet()
		{
			var dict = new Dictionary<string,Option>();
			var cfg = Configuration; 

			foreach(var pinfo in GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
			{
				if(Option.IsCandidate(pinfo))
				{
					var option = new Option(pinfo);
					var longName = cfg.LongNamePrefix+option.LongName;
										
					dict.Add(longName,option);
					
					if(option.ShortNames.Length > 0)
					{
						foreach(var shortName in option.ShortNames.Split(shortNameSeparators))
						{
							dict.Add(cfg.ShortNamePrefix+shortName,option);
						}
					}
				}
			}
			
			return dict;
		}
				
		private Argument splitArgument(string argString)
		{
			var config = Configuration;
			var argument = new Argument();
			
			//has prefix?
			if(argString.StartsWith(config.LongNamePrefix) ||
				argString.StartsWith(config.ShortNamePrefix))
			{
				//has value separator?
				int valueStartIndex = argString.IndexOf(config.NameValueSeparator);
				if(valueStartIndex > -1)
				{
					argument.Name = argString.Substring(0,valueStartIndex);
					argument.Value = argString.Substring(valueStartIndex + config.NameValueSeparator.Length);
					argument.Value = Argument.StripEnclosingQuotes(argument.Value);
				}
				else
				{
					argument.Name = argString;
				}

				return argument;
			}
			else
			{
				//this is just a single value
				argument.Value = argString;
				argument.Value = Argument.StripEnclosingQuotes(argument.Value);
				return argument;
			}
		}

		public virtual string GetHeadingString()
		{
			return Path.GetFileNameWithoutExtension(Assembly.GetEntryAssembly().Location); 
		}

		public virtual string GetUsageString()
		{
			return "Usage is: program [options] values";
		}

		public virtual string GetHelpString()
		{
			var options = createOptionSet();
			var set = new Dictionary<Option,Option>(); 
			var lineBuilder = new StringBuilder(1024);
			var namesBuilder = new StringBuilder(256);

			foreach(var value in options.Values)
			{
				if(!set.ContainsKey(value))
				{
					set.Add(value,null);
				}
			}

			foreach(var option in set.Keys)
			{
				namesBuilder.Length = 0;
				namesBuilder.AppendFormat("{0}",Configuration.LongNamePrefix+option.LongName);

				if(!string.IsNullOrEmpty(option.ShortNames))
				{
					foreach(var shortName in option.ShortNames.Split(shortNameSeparators))
						namesBuilder.AppendFormat(",{0}{1}",Configuration.ShortNamePrefix,shortName);
				}

				if(option.OptionType != typeof(bool))
				{
					namesBuilder.AppendFormat("=VALUE");
				}
				
				lineBuilder.AppendFormat("{0,-28}{1}\n",namesBuilder.ToString(),option.Help);
			}
			
			return lineBuilder.ToString();
		}
	}

	/// <summary>
	/// Configuration settings for a CommandLine.
	/// </summary>
	public class CommandLineConfiguration
	{
		//public string UsageString { get; set; }
		//public bool UseLowercaseLongNames { get; set; }
		public string LongNamePrefix { get; set; }
		public string ShortNamePrefix { get; set; }
		public string NameValueSeparator { get; set; }
		/// <summary>
		/// Gets or sets whether the CommandLine will generate errors on unknown options. 
		/// </summary>
		public bool IgnoreUnknownOptions { get; set; }

		public CommandLineConfiguration()
		{
			//UsageString = "program [options] values";
			//UseLowercaseLongNames = true;
			LongNamePrefix = "--";
			ShortNamePrefix = "-";
			NameValueSeparator = "=";
			//ValueListSeparator = ";";
		}

		/// <summary>
		/// Creates a new CommandLineConfiguration in the style of many *nix based programs. 
		/// </summary>
		/// <returns>CommandLineConfiguration</returns>
		public static CommandLineConfiguration CreateUnixStyle()
		{
			var cfg = new CommandLineConfiguration()
			{
				LongNamePrefix = "--",
				ShortNamePrefix = "-",
				NameValueSeparator = "="
			};

			return cfg;
		}
		
		/// <summary>
		/// Creates a new CommandLineConfiguration that is similiar to many Windows based programs.
		/// </summary>
		/// <returns>CommandLineConfiguration</returns>
		public static CommandLineConfiguration CreateMSStyle()
		{
			var cfg = new CommandLineConfiguration()
			{
				LongNamePrefix = "/",
				ShortNamePrefix = "/",
				NameValueSeparator = ":"
			};

			return cfg;
		}
	}

	/// <summary>
	/// Provides additional control for Options, such as ShortNames and Help strings. 
	/// </summary>
	[AttributeUsage(AttributeTargets.Property)]
	public class OptionAttribute : Attribute
	{
		public string LongName { get; set; }
		public string ShortNames { get; set; }
		public string Help { get; set; }
	}

	/// <summary>
	/// Properties marked with this attribute will not be treated as options.
	/// </summary>
	[AttributeUsage(AttributeTargets.Property)]
	public class OptionIgnoreAttribute : Attribute
	{
	}

	/// <summary>
	/// Represents an error setting up or parsing a CommandLine. 
	/// </summary>
	public class CommandLineException : Exception
	{
		public CommandLine CommandLine { get; protected set; }
		public CommandLineException(CommandLine commandLine) : base()
		{
			CommandLine = commandLine;
		}
		public CommandLineException(CommandLine commandLine,string message) : base(message)
		{
			CommandLine = commandLine;
		}
	}

	/// <summary>
	/// Thrown when no data exists in the string array passed to CommandLine.Parse() 
	/// </summary>
	public class CommandLineNoInputException : CommandLineException
	{
		public CommandLineNoInputException(CommandLine commandLine)
			: base(commandLine)
		{
		}
	}

	#region Internals

	[AttributeUsage(AttributeTargets.Property)]
	internal class OptionSpecialAttribute : Attribute
	{
	}

	internal struct Argument
	{
		public string Name;
		public string Value;

		public bool HasName { get { return !string.IsNullOrEmpty(Name); } }
		public bool HasValue { get { return !string.IsNullOrEmpty(Value); } }

		public static string StripEnclosingQuotes(string s)
		{
			if(s.StartsWith("\""))
				s=s.Substring(1);

			if(s.EndsWith("\""))
				s=s.Substring(0,s.Length - 1);

			return s;
		}

	}

	internal class Option
	{
		public PropertyInfo PropertyInfo { get; private set; }
		public Type OptionType { get { return PropertyInfo.PropertyType; } } 
		//public bool IsGenericIList { get; private set; }
		public bool IsSpecial { get; private set; }
		public string LongName { get; private set; }
		public string ShortNames { get; private set; }
		public string Help { get; private set; }
		
		public Option(PropertyInfo propInfo)
		{
			OptionAttribute option=null;
			OptionIgnoreAttribute ignore=null;
			OptionSpecialAttribute special=null;

			getOptionAttributes(propInfo,out option,out ignore,out special);

			PropertyInfo = propInfo;
			LongName = propInfo.Name;
			ShortNames = "";
			Help = "";
			
			if(option!=null)
			{
				LongName=!string.IsNullOrEmpty(option.LongName)?option.LongName:propInfo.Name; 
				ShortNames=!string.IsNullOrEmpty(option.ShortNames)?option.ShortNames:"";
				Help=option.Help;
			}
		}

		public void SetValue(CommandLine cmdLine,Argument arg)
		{
			var ptype = PropertyInfo.PropertyType;

			if(ptype == typeof(bool))
				setBooleanValue(cmdLine,arg);
			else if(ptype == typeof(int))
				setInt32Value(cmdLine,arg);
			else if(ptype == typeof(float))
				setSingleValue(cmdLine,arg);
			else if(ptype == typeof(string))
				setStringValue(cmdLine,arg);
			else if(ptype.IsEnum)
				setEnumValue(cmdLine,arg);
		}

		private void setBooleanValue(CommandLine cmdLine,Argument arg)
		{
			bool cval = false;

			if(arg.HasName & !arg.HasValue)
				cval = true;
			else if(arg.HasName & arg.HasValue)
			{
				if(arg.Value == "+")
					cval = true;
				else if(arg.Value == "-")
					cval = false;
				else
					cval = Convert.ToBoolean(arg.Value);
			}
			else
				throw new Exception("This should never happen.");

			PropertyInfo.SetValue(cmdLine,cval,null);
		}

		private void setInt32Value(CommandLine cmdLine,Argument arg)
		{
			int cval = 0;

			if(arg.HasName && arg.HasValue)
				cval = Convert.ToInt32(arg.Value);
			else
				throw new Exception("This should never happen.");

			PropertyInfo.SetValue(cmdLine,cval,null);
		}

		private void setSingleValue(CommandLine cmdLine,Argument arg)
		{
			float cval = 0;

			if(arg.HasName && arg.HasValue)
				cval = Convert.ToSingle(arg.Value);
			else
				throw new Exception("This should never happen.");

			PropertyInfo.SetValue(cmdLine,cval,null);
		}

		private void setStringValue(CommandLine cmdLine,Argument arg)
		{
			string cval = null;

			if(arg.HasName && arg.HasValue)
				cval = arg.Value;
			else
				throw new Exception("This should never happen.");

			PropertyInfo.SetValue(cmdLine,cval,null);
		}

		private void setEnumValue(CommandLine cmdLine,Argument arg)
		{
			object cval = null;
			
			if(arg.HasName && arg.HasValue)
				cval = Enum.Parse(PropertyInfo.PropertyType,arg.Value,true);
			else
				throw new Exception("This should never happen.");

			PropertyInfo.SetValue(cmdLine,cval,null);
		}
		
		//determines if a property can be used as an Option.
		public static bool IsCandidate(PropertyInfo propInfo)
		{
			OptionAttribute option;
			OptionIgnoreAttribute ignore;
			OptionSpecialAttribute special;

			getOptionAttributes(propInfo,out option,out ignore,out special);
			
			if(special!=null)
				return true;

			if(ignore!=null)
				return false;

			if(option != null)
				return true;
			
			if(!propInfo.CanWrite)
				return false;
			
			if(propInfo.PropertyType == typeof(bool))
				return true;

			if(propInfo.PropertyType == typeof(string))
				return true;

			if(propInfo.PropertyType == typeof(int))
				return true;

			if(propInfo.PropertyType == typeof(float))
				return true;

			if(propInfo.PropertyType.IsEnum)
				return true;
			
			return false;
		}

		private static void getOptionAttributes(PropertyInfo propInfo,
										out OptionAttribute option,
										out OptionIgnoreAttribute ignore,
										out OptionSpecialAttribute special)
		{
			option = null;
			ignore = null;
			special = null;

			foreach(var attribute in propInfo.GetCustomAttributes(true))
			{
				if(attribute is OptionAttribute)
					option = attribute as OptionAttribute;

				if(attribute is OptionIgnoreAttribute)
					ignore = attribute as OptionIgnoreAttribute;

				if(attribute is OptionSpecialAttribute)
					special = attribute as OptionSpecialAttribute;
			}

		}
	}
	
	#endregion

}
